HEX
Server: Apache/2.4.68 (Debian)
System: Linux as-cs-widget-demo-us-central1 6.1.0-44-cloud-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.164-1 (2026-03-09) x86_64
User: root (0)
PHP: 8.2.32
Disabled: NONE
Upload Files
File: /var/www/kevin-demo/wp-content/plugins/allspice/includes/rating-comments.php
<?php
/**
 * Rail ratings -> real WordPress comments.
 *
 * One PUBLIC route the float rail's rating bubble posts to. It inserts a normal WP comment
 * through wp_new_comment() - so the site's own moderation settings, flood control, dedupe
 * and notification emails all apply exactly as if the reader had used the comment form -
 * and attaches WPRM's comment-rating meta so an approved comment counts toward the recipe
 * card's stars.
 *
 * Product rules (approved mock, 2026-08-25):
 *   - Comments ride only 4-5 star ratings; the client never offers the comment step below
 *     that, and this endpoint refuses it too (defense in depth, not the primary gate).
 *   - comment text, author name and email are all required (the standard WP comment
 *     fields). The email is stored on the comment as always, never displayed by us.
 *   - Nothing auto-publishes: whatever the site's moderation settings decide, decides.
 *
 * Abuse posture for a public route: a honeypot field (bots that fill "website" get a
 * pretend success and no comment), a per-IP transient rate limit, hard length caps, and
 * comments_open() honored - a post with closed comments takes nothing.
 */

if (!defined('ABSPATH')) { exit; }

const ALLSPICE_RATING_COMMENT_MAX_LEN = 3000;
const ALLSPICE_RATING_RATE_LIMIT = 6;          // submissions per IP per window
const ALLSPICE_RATING_RATE_WINDOW = 3600;      // seconds

add_action('rest_api_init', 'allspice_rating_register_routes');
function allspice_rating_register_routes(): void {
    register_rest_route('allspice/v1', '/rating-comment', [
        'methods' => 'POST',
        'callback' => 'allspice_rating_comment_submit',
        'permission_callback' => '__return_true',
        'args' => [
            'rating' => ['type' => 'integer', 'required' => true],
            'comment' => ['type' => 'string', 'required' => true],
            'author' => ['type' => 'string', 'required' => true],
            'email' => ['type' => 'string', 'required' => true],
            'post_url' => ['type' => 'string', 'required' => true],
            'website' => ['type' => 'string', 'required' => false],
        ],
    ]);
}

function allspice_rating_comment_submit($request) {
    // Honeypot: real readers never see this field. Answer success so bots learn nothing.
    if (trim((string)$request->get_param('website')) !== '') {
        return rest_ensure_response(['ok' => true]);
    }

    $ip = isset($_SERVER['REMOTE_ADDR']) ? (string)$_SERVER['REMOTE_ADDR'] : '';
    $bucket = 'allspice_rating_rl_' . md5($ip);
    $count = (int)get_transient($bucket);
    if ($count >= ALLSPICE_RATING_RATE_LIMIT) {
        return new WP_Error('allspice_rating_limited', 'Too many submissions. Try again later.', ['status' => 429]);
    }
    set_transient($bucket, $count + 1, ALLSPICE_RATING_RATE_WINDOW);

    $rating = (int)$request->get_param('rating');
    if ($rating < 4 || $rating > 5) {
        // The client only offers the comment step at 4-5 stars; anything else here is not ours.
        return new WP_Error('allspice_rating_gate', 'Comments are only accepted with a 4 or 5 star rating.', ['status' => 422]);
    }

    $comment = trim(wp_strip_all_tags((string)$request->get_param('comment')));
    if (mb_strlen($comment) < 3) {
        return new WP_Error('allspice_rating_comment', 'Comment text is required.', ['status' => 422]);
    }
    if (mb_strlen($comment) > ALLSPICE_RATING_COMMENT_MAX_LEN) {
        $comment = mb_substr($comment, 0, ALLSPICE_RATING_COMMENT_MAX_LEN);
    }

    $author = sanitize_text_field((string)$request->get_param('author'));
    if ($author === '' || mb_strlen($author) > 100) {
        return new WP_Error('allspice_rating_author', 'A name is required.', ['status' => 422]);
    }
    $email = sanitize_email((string)$request->get_param('email'));
    if (!is_email($email)) {
        return new WP_Error('allspice_rating_email', 'A valid email is required.', ['status' => 422]);
    }

    $post_url = esc_url_raw((string)$request->get_param('post_url'));
    $post_id = $post_url ? url_to_postid($post_url) : 0;
    if (!$post_id) {
        return new WP_Error('allspice_rating_post', 'No post found for that URL.', ['status' => 404]);
    }
    if (!comments_open($post_id)) {
        return new WP_Error('allspice_rating_closed', 'Comments are closed on this post.', ['status' => 409]);
    }

    $commentdata = [
        'comment_post_ID' => $post_id,
        'comment_author' => $author,
        'comment_author_email' => $email,
        'comment_author_url' => '',
        'comment_content' => $comment,
        'comment_type' => 'comment',
        'comment_parent' => 0,
        'user_id' => 0,
    ];

    // $wp_error = true: a refusal (dedupe, flood, spam plugin) comes back as WP_Error
    // instead of dying, and the reader gets an honest failure in the bubble.
    $comment_id = wp_new_comment($commentdata, true);
    if (is_wp_error($comment_id)) {
        $status = $comment_id->get_error_code() === 'comment_duplicate' ? 409 : 400;
        return new WP_Error('allspice_rating_refused', $comment_id->get_error_message(), ['status' => $status]);
    }

    // WPRM reads this meta for comment ratings; an approved comment then counts toward the
    // recipe card's aggregate stars. Harmless on sites without WPRM. The source marker lets
    // publishers (and our sync) tell rail-collected comments apart.
    update_comment_meta((int)$comment_id, 'wprm-comment-rating', $rating);
    update_comment_meta((int)$comment_id, 'allspice_source', 'rail_rating');

    return rest_ensure_response(['ok' => true, 'commentId' => (int)$comment_id]);
}